再读 Webpack 源码,各个击破(二):深入 Tapable 及 Hook

前言

Tapable 库是一个很小的库,它为 Webpack 提供了一个核心能力,就是事件流机制。通过 Hook (钩子)能够让开发者轻松的访问到构建流程中的各个阶级和数据对象。本节将对 Tapable 库源码进行分析。


1、Tapable 库

在 Webpack 官方文档中这样介绍 Tapable 库:

1
这个小型库是 webpack 的一个核心工具,但也可用于其他地方, 以提供类似的插件接口。在 webpack 中的许多对象都扩展自 Tapable 类。 它对外暴露了 tap,tapAsync 和 tapPromise 等方法, 插件可以使用这些方法向 webpack 中注入自定义构建的步骤,这些步骤将在构建过程中触发。

Tap 有敲击\窃听的意思,一个词两个意思。因此,从名字上就很容易理解它的功能。本文使用到的版本为:

1
Tapable 1.1.3

1.1、三大核心

这个库很小,仅16个文件,除了 index.js 之外,还有 15 个类文件。该库有三个核心:

  • Tapable.js ,webpack 中很多类继承了该类,比如 compiler、compilation 等
  • HookCodeFactory.js,针对不同的 hook,生成需要控制流程的函数代码。该库中很多 hook 文件中都有配套的工厂类继承于它
  • Hook.js,不同类型钩子的基类,提供了基本的 tap 方法,不同类型的子类可以重写这些方法。而 call 方法则由上面的工厂类去实现

接下来,我们先看下 Hook 这个核心部分。毕竟所有的东西都围绕着 Hook 的 tap、call 方法进行流程的运转。

1.2、约定

本文中如无特殊说明,tap 指代 xxHook 的 tap\tapAsync\tapPromise 这些注册方法,call 指代 call\callAsync\promise 这些方法。

2、深入 Hook

2.1、Hook 的类型

每个 hook 都可以通过 tap 注册一个或多个函数。这些函数如何执行要看 hook 的类型:

  • 基本类型(名字中没有 Waterfall、Bail 或 Loop)。这类 hook 会按照注册的顺序依次执行钩子函数。
  • Waterfall 类型。也是依次调用注册的函数。不同于基本类型的是它会把函数的返回值传递给下一个函数。
  • Bail 类型。它允许提前退出。当任意一个注册的函数有返回值(非undefined)时,则停止剩余函数的执行。
  • Loop 类型。如果任意一个函数返回非 undefined 值则从头执行注册的函数。直到所有的函数都返回 undefined。

此外,hooks 可以是同步的也可以是异步的。反映在名字上,有 Sync、AsyncSeries、AsyncParallel 钩子类:

  • Sync。同步钩子可以注册同步函数,使用 myHook.tap() 方法。
  • AsyncSeries。异步串行钩子可以使用注册异步的,基于回调和基于 promise 的函数(使用 myHook.tap()、myHook.tapAsync() 和 myHook.tapPromise())。会依次调用每一个异步函数。
  • AsyncParallel。异步并行钩子可以注册异步,基于回调和基于 promise 的函数(使用 myHook.tap()、myHook.tapAsync() 和 myHook.tapPromise())。然而它会并行的运行每个异步函数。

比如:AsyncSeriesWaterfallHook 可以注册异步函数,串行运行它们,并把函数的返回值传递给下一个函数。

2.2、tap 方法

上文提到了三种 tap 方法可以来注册钩子,在 webpack 中可以访问到各个执行阶段的数据。我们现在来看一下 Hooks.js 这个基类里 tap 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
node_modules\tapable\lib\Hook.js
class Hook {
constructor(args) {
if (!Array.isArray(args)) args = [];
this._args = args; // 参数名列表
this.taps = []; // 存放通过 tap 方法注册的函数
this.interceptors = []; // 拦截器
this.call = this._call; // call 方法
this.promise = this._promise; // 基于promise的call方法
this.callAsync = this._callAsync; // 异步 call 方法
this._x = undefined;
}
tap(options, fn) {
options = Object.assign({ type: "sync", fn: fn }, options);
options = this._runRegisterInterceptors(options);
this._insert(options);
}
tapAsync(options, fn) {
options = Object.assign({ type: "async", fn: fn }, options);
options = this._runRegisterInterceptors(options);
this._insert(options);
}
tapPromise(options, fn) {
options = Object.assign({ type: "promise", fn: fn }, options);
options = this._runRegisterInterceptors(options);
this._insert(options);
}
_resetCompilation() {
this.call = this._call;
this.callAsync = this._callAsync;
this.promise = this._promise;
}
_insert(item) {
this._resetCompilation();
//
this.taps[i] = item;
}
}

这里我们看到了三个 tap 方法,唯一的不同就是 type,然后得到一个 options 对象。然后进行拦截器注册的处理,这里本文暂时忽略,最后来到就是执行 _insert 方法。该方法代码较多,删减之后保留了核心的两行,一个调用 _resetCompilation 进行 call 函数的重置,最后就是都放在了 taps 这个数组里面,中间省去的是确定注册函数插入的位置。

2.3、call 方法

在看过 tap 方法之后,完成了钩子函数的注册,call 方法正式登场。还是 Hook.js 这个类文件里的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
node_modules\tapable\lib\Hook.js
class Hook {
compile(options) {
// 最终来到了这里,这个方法最终是有子类来实现的
throw new Error("Abstract: should be overriden");
}
_createCall(type) {
return this.compile({
taps: this.taps,
interceptors: this.interceptors,
args: this._args,
type: type
});
}
}
function createCompileDelegate(name, type) {
return function lazyCompileHook(...args) {
this[name] = this._createCall(type);
return this[name](...args);
};
}
Object.defineProperties(Hook.prototype, {
_call: {
value: createCompileDelegate("call", "sync"),
configurable: true,
writable: true
},
_promise: {
value: createCompileDelegate("promise", "promise"),
configurable: true,
writable: true
},
_callAsync: {
value: createCompileDelegate("callAsync", "async"),
configurable: true,
writable: true
}
});

我们在 constructor 中看到了 this.callAsync = this._callAsync 。然后上面这段源码中,我们看到通过 Object.defineProperties 定义了 _callAsync 这个函数,它是通过 createCompileDelegate 生成了一个 lazyCompileHook 函数。里面进行了 this._createCall(type) 调用,这个方法继续调用了 Hook.compile 函数,这个函数是一个是由子类进行重写的一个函数。

2.4、示例:AsyncParallelHook

1
2
3
4
5
6
7
8
class Compiler extends Tapable {
constructor(context) {
super();
this.hooks = {
make: new AsyncParallelHook(["compilation"])
}
}
}

回到我们上一篇中,compiler.hooks.make.callAsync 这个方法,我们可以看到 make 这个 hook 的类型是 AsyncParallelHook。这个类也是 Hook 类的子类,我们来看一下这个类的源码部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
node_modules\tapable\lib\AsyncParallelHook.js
class AsyncParallelHookCodeFactory extends HookCodeFactory {
// 实现HookCodeFactory基类中未实现的 content 方法
content({ onError, onDone }) {
return this.callTapsParallel({
onError: (i, err, done, doneBreak) => onError(err) + doneBreak(true),
onDone
});
}
}
const factory = new AsyncParallelHookCodeFactory();
class AsyncParallelHook extends Hook {
compile(options) {
//
factory.setup(this, options);
//
return factory.create(options);
}
}
Object.defineProperties(AsyncParallelHook.prototype, {
// 重新设置 _call 属性,也就是 call 这个方法。因为是异步Hook,不提供这种同步调用
_call: { value: undefined, configurable: true, writable: true }
});

这里的源码不是很复杂,基本上做了下面几个事情:

  • 重新设置了_call 属性
  • 实现了 Hook 基类的 compile 方法,并调用工厂对象的 setup,返回 factory.create 函数的返回值
  • 实现工厂类的 content 方法

这里涉及了工厂类的几个方法,因此我们需要看一下 HookCodeFactory 这个类源码的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
node_modules\tapable\lib\HookCodeFactory.js
class HookCodeFactory {
create(options) {
this.init(options);
let fn;
switch (this.options.type) {
case "sync":
break;
case "async":
// 动态函数生成,不同 Hook 类型执行的控制函数
fn = new Function(
// ["compilation", "callback"]
this.args({
after: "_callback"
}),
'"use strict";\n' +
this.header() +
// 调用子类的 content,拿到剩余的代码串,
// 而 AsyncParallelHook又调回了 callTapsParallel
this.content({
onError: err => `_callback(${err});\n`,
onResult: result => `_callback(null, ${result});\n`,
onDone: () => "_callback();\n"
})
);
break;
case "promise":
break;
}
this.deinit();
return fn;
}
setup(instance, options) {
// 拿到之前 Hook 里 taps 收集到的钩子函数
instance._x = options.taps.map(t => t.fn);
}
init(options) {
this.options = options;
// 拿到参数名字,比如 make: new AsyncParallelHook(["compilation"]),拿到 ["compilation"]
this._args = options.args.slice();
}
header() {
let code = "";
// 拿到所有的钩子函数
code += "var _x = this._x;\n";
return code;
}
}

ok,经过几个类源码的分析,我们可以得出通过 Hook 的 tap 方法收集钩子函数,调用 Hook 的 call 方法,实际是执行工厂动态生成的控制函数。根据钩子的类型,就会有不同的控制函数。每一种 Hook 子类定义文件中都会有一个 HookCodeFactory 子类的定义来配合实现控制函数的生成。这里我们以一个示例代码,来看看到底生成的动态函数是什么样子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
asyncParallel.js
const {AsyncParallelHook} = require('tapable')
const hooks = new AsyncParallelHook(['name'])
hooks.tapAsync('h', (name, callback)=>{
console.log('h', name, this)
callback()
})
hooks.tapAsync('h1', (name, callback)=>{
console.log('h1', name)
return callback()
})
hooks.tap('h2', name=>{
console.log('h2', name)
})
hooks.callAsync('robin', ()=>{
console.log('callAsync')
})
node_modules\tapable\lib\Hook.js
function createCompileDelegate(name, type) {
// 这里的 name = 'callAsync' type = 'async'
return function lazyCompileHook(...args) {
// 这里的 args 就是 hooks.callAsync 传入的两个参数: 'robin' 和箭头函数
// this._createCall 就是下面的动态控制函数
this[name] = this._createCall(type);
// 传入参数,进行调用
return this[name](...args);
};
}
生成的控制函数:
(function anonymous(name, _callback) {
"use strict";
var _context;
var _x = this._x; // 钩子
do {
var _counter = 3; // 钩子个数
var _done = () = >{ // 全部钩子完成的回调函数
_callback();
};
if (_counter <= 0) break;
var _fn0 = _x[0];
_fn0(name, _err0 = >{// 第一个钩子函数 h
if (_err0) {
if (_counter > 0) {
_callback(_err0);
_counter = 0;
}
} else {
if (--_counter === 0) _done();
}
});
if (_counter <= 0) break;
var _fn1 = _x[1];
_fn1(name, _err1 = >{// 第二个钩子函数 h1
if (_err1) {
if (_counter > 0) {
_callback(_err1);
_counter = 0;
}
} else {
if (--_counter === 0) _done();
}
});
if (_counter <= 0) break;
var _fn2 = _x[2];// 第三个钩子函数 h2
var _hasError2 = false;
try {
_fn2(name);
} catch(_err) {
_hasError2 = true;
if (_counter > 0) {
_callback(_err);
_counter = 0;
}
}
if (!_hasError2) {
if (--_counter === 0) _done();
}
} while ( false );
})

因为是异步并发 Hook 类型,所以钩子函数都是同步执行的,然后在钩子的回调里进行的流程控制,去计数钩子是否完成(无论是正常还是产生了 err)。最后才去执行了 _callback(),即我们在 hooks.callAsync 定义的箭头函数。这里有一个问题需要注意一下就是,我们看到 h 和 h1 我们使用的 tapAsync,而 h2 我们用的是 tap。因此,在控制上有一些差异,前面两个我们在控制函数中多了一个参数:回调函数,也就是 hooks.tapAsync(‘h1’, (name, callback)) 里的这个 callback,这个 callback 函数是要执行的,否则最后 hooks.callAsync 里的箭头函数是无法执行到的。

分析到这里,我们就了解到了 compiler.hooks.make.callAsync 是怎么执行到了 compilation.finish()。其他类型的 Hook,本文不再一一分析,留给读者去学习。下一节,我们继续讲解 Module 具体是如何构建的。